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/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)
|