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/report.py ADDED
@@ -0,0 +1,203 @@
1
+ """Problem reports: one zip file a user can send, with what is needed to understand a problem.
2
+
3
+ Inside: a readme, the user's note, the environment, the doctor checks, a summary of the configuration, a summary of
4
+ the indexed project (counts, findings, warnings) and the log files. Never inside: source code, file contents, the
5
+ session token. Paths under the home folder are shortened to `~`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ import sys
13
+ import time
14
+ import zipfile
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from stackthread import __version__, logs
19
+
20
+ if TYPE_CHECKING:
21
+ from stackthread.config import Config
22
+ from stackthread.project import Snapshot
23
+
24
+ README = """stackthread problem report
25
+ ==========================
26
+
27
+ Made {made} by stackthread {version}.
28
+
29
+ What is inside
30
+ note.txt what you wrote about the problem, if anything
31
+ environment.json versions of stackthread, Python, Node.js and the operating system
32
+ doctor.txt the same checks as `stackthread doctor`
33
+ config.json a summary of the project settings
34
+ project.json counts, findings and warnings from the last indexing run
35
+ logs/ stackthread's log files from this project (.stackthread/logs)
36
+
37
+ What is not inside
38
+ No source code and no file contents. Paths under your home folder are shown as ~.
39
+ The session token is masked.
40
+
41
+ Send this file to whoever looks after stackthread for you, with a few words about what you were doing.
42
+ """
43
+
44
+
45
+ def _hide_home(text: str) -> str:
46
+ home = str(Path.home())
47
+ for form in {home, home.replace("\\", "/"), home.replace("\\", "\\\\")}:
48
+ if form and len(form) > 3:
49
+ text = text.replace(form, "~")
50
+ return text
51
+
52
+
53
+ def doctor(config: Config | None, config_error: str = "") -> tuple[bool, list[str]]:
54
+ """The checks behind `stackthread doctor`, as lines of text."""
55
+ import platform
56
+
57
+ from stackthread.lang.js.worker import NodeUnavailable, extractor_path, find_node, node_version
58
+
59
+ ok = True
60
+ lines = [f"stackthread {__version__} on Python {platform.python_version()} ({sys.platform})"]
61
+ try:
62
+ node = find_node()
63
+ v = node_version(node)
64
+ lines.append(f" node ok {node} ({v[0]}.{v[1]})" if v else f" node ok {node}")
65
+ except NodeUnavailable as e:
66
+ ok = False
67
+ lines.append(f" node FAIL {e}")
68
+ script = extractor_path()
69
+ lines.append(f" extractor {'ok ' if script else 'FAIL'} {script or 'missing: run npm run build in js/'}")
70
+ ok &= script is not None
71
+ static = Path(__file__).resolve().parent / "static" / "index.html"
72
+ lines.append(
73
+ f" browser UI {'ok ' if static.is_file() else 'FAIL'} {static if static.is_file() else 'missing'}"
74
+ )
75
+ ok &= static.is_file()
76
+ if config is None:
77
+ ok = False
78
+ lines.append(f" config FAIL {config_error or 'no configuration'}")
79
+ return ok, lines
80
+ lines.append(f" config ok {config.path or 'detected'}")
81
+ for s in config.sources:
82
+ exists = config.source_root(s).is_dir()
83
+ lines.append(
84
+ f" {s.name:<10} {s.lang:<10} {s.role:<8} {config.source_root(s)} {'' if exists else '(missing)'}"
85
+ )
86
+ ok &= exists
87
+ if any(s.is_python for s in config.sources):
88
+ lines.append(
89
+ " python syntax ok this interpreter reads code up to Python "
90
+ f"{sys.version_info.major}.{sys.version_info.minor}; install stackthread in the project's"
91
+ " environment if the project uses newer syntax"
92
+ )
93
+ folder = logs.log_dir(config.state_dir)
94
+ lines.append(f" logs {folder if folder.is_dir() else 'none yet'}")
95
+ return ok, lines
96
+
97
+
98
+ def _config_summary(config: Config) -> dict[str, Any]:
99
+ return {
100
+ "name": config.name,
101
+ "file": str(config.path) if config.path else None,
102
+ "detected": config.detected,
103
+ "sources": [
104
+ {
105
+ "name": s.name,
106
+ "lang": s.lang,
107
+ "role": s.role,
108
+ "root": s.root,
109
+ "include": s.include,
110
+ "exclude": s.exclude,
111
+ "frameworks": s.frameworks,
112
+ }
113
+ for s in config.sources
114
+ ],
115
+ "http_links": [{"from": lk.frm, "to": lk.to} for lk in config.http.links],
116
+ "views": len(config.views),
117
+ "concepts": [c.attribute for c in config.concepts],
118
+ "server": {
119
+ "port": config.server.port,
120
+ "editor": config.server.editor,
121
+ "edit": config.server.edit,
122
+ "format": sorted(config.server.format),
123
+ },
124
+ "rules": [r.name for r in config.layer_rules],
125
+ "adapters": config.adapters,
126
+ "log": {"level": config.log.level, "size_kb": config.log.size_kb, "keep": config.log.keep},
127
+ }
128
+
129
+
130
+ def _project_summary(snapshot: Snapshot, activity: dict[str, Any] | None) -> dict[str, Any]:
131
+ g = snapshot.graph
132
+ kinds: dict[str, int] = {}
133
+ for n in g.nodes.values():
134
+ kinds[n.kind] = kinds.get(n.kind, 0) + 1
135
+ edges: dict[str, int] = {}
136
+ for e in g.edges:
137
+ edges[e.kind] = edges.get(e.kind, 0) + 1
138
+ return {
139
+ "rev": snapshot.rev,
140
+ "build_ms": round(snapshot.built_ms),
141
+ "files": {source: len(files) for source, files in snapshot.facts.items()},
142
+ "nodes": kinds,
143
+ "edges": edges,
144
+ "warnings": snapshot.warnings,
145
+ "findings": [f.to_json() for f in g.findings[:300]],
146
+ "activity": activity,
147
+ }
148
+
149
+
150
+ def build(
151
+ config: Config | None,
152
+ snapshot: Snapshot | None = None,
153
+ note: str = "",
154
+ activity: dict[str, Any] | None = None,
155
+ problem: str = "",
156
+ ) -> tuple[bytes, list[str]]:
157
+ """The report as zip bytes, and the names inside it."""
158
+ names: list[str] = []
159
+ buf = io.BytesIO()
160
+
161
+ def add(name: str, text: str) -> None:
162
+ names.append(name)
163
+ zf.writestr(name, _hide_home(text))
164
+
165
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
166
+ add("README.txt", README.format(made=time.strftime("%Y-%m-%d %H:%M:%S %z"), version=__version__))
167
+ if note.strip():
168
+ add("note.txt", note.strip()[:20000] + "\n")
169
+ env = logs.environment()
170
+ try:
171
+ from stackthread.lang.js.worker import NodeUnavailable, find_node, node_version
172
+
173
+ v = node_version(find_node())
174
+ env["node"] = f"{v[0]}.{v[1]}" if v else "unknown"
175
+ except NodeUnavailable as e:
176
+ env["node"] = f"unavailable: {e}"
177
+ add("environment.json", json.dumps(env, indent=2))
178
+ ok, lines = doctor(config, problem)
179
+ add("doctor.txt", "\n".join(lines) + f"\n\nall checks passed: {ok}\n")
180
+ if problem:
181
+ add("problem.txt", problem + "\n")
182
+ if config is not None:
183
+ add("config.json", json.dumps(_config_summary(config), indent=2))
184
+ if snapshot is not None:
185
+ add("project.json", json.dumps(_project_summary(snapshot, activity), indent=2, default=str))
186
+ if config is not None:
187
+ for path in logs.files(config.state_dir):
188
+ try:
189
+ text = path.read_text(encoding="utf-8", errors="replace")
190
+ except OSError:
191
+ continue
192
+ add(f"logs/{path.name}", logs._redact.scrub(text))
193
+ return buf.getvalue(), names
194
+
195
+
196
+ def save(config: Config | None, data: bytes, out: Path | None = None) -> Path:
197
+ """Write a report next to the logs (or to `out`) and return its path."""
198
+ if out is None:
199
+ folder = (config.state_dir if config is not None else Path.cwd() / ".stackthread") / "reports"
200
+ folder.mkdir(parents=True, exist_ok=True)
201
+ out = folder / f"stackthread-report-{time.strftime('%Y%m%d-%H%M%S')}.zip"
202
+ out.write_bytes(data)
203
+ return out
stackthread/rules.py ADDED
@@ -0,0 +1,48 @@
1
+ """Rules that produce findings: layer contracts on imports, and summaries the CLI `check` command reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from stackthread.config import Config, LayerRule
6
+ from stackthread.model import Finding, Graph
7
+
8
+
9
+ def _layer_of(node_name: str, rule: LayerRule) -> int | None:
10
+ best: tuple[int, int] | None = None # (index, prefix length)
11
+ for i, prefix in enumerate(rule.order):
12
+ inside = node_name == prefix or node_name.startswith((prefix + ".", prefix + "/"))
13
+ if inside and (best is None or len(prefix) > best[1]):
14
+ best = (i, len(prefix))
15
+ return best[0] if best else None
16
+
17
+
18
+ def layer_rules(graph: Graph, config: Config) -> None:
19
+ for rule in config.layer_rules:
20
+ for edge in graph.edges:
21
+ if edge.kind != "imports":
22
+ continue
23
+ src, dst = graph.nodes[edge.src], graph.nodes[edge.dst]
24
+ if rule.source and src.source != rule.source:
25
+ continue
26
+ if "test" in src.tags:
27
+ continue
28
+ a = _layer_of(src.qualname, rule)
29
+ b = _layer_of(dst.qualname, rule)
30
+ if a is None or b is None or a == b:
31
+ continue
32
+ if b > a:
33
+ site = edge.site
34
+ graph.finding(
35
+ Finding(
36
+ "layers",
37
+ "error",
38
+ f"{rule.name}: {src.qualname} imports {dst.qualname}, which sits above it "
39
+ f"({rule.order[a]} may only depend on earlier layers)",
40
+ site.file if site else src.file,
41
+ site.line if site else 0,
42
+ src.id,
43
+ )
44
+ )
45
+
46
+
47
+ def run(graph: Graph, config: Config) -> None:
48
+ layer_rules(graph, config)
@@ -0,0 +1,5 @@
1
+ """The local server: UI, JSON API, live events, guarded edits (design §13, §14)."""
2
+
3
+ from stackthread.server.app import serve
4
+
5
+ __all__ = ["serve"]