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/config.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""stackthread.toml (or [tool.stackthread] in pyproject.toml): loading, defaults and strict validation (design §6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = "stackthread.toml"
|
|
12
|
+
LANGS = {
|
|
13
|
+
"python": "python",
|
|
14
|
+
"py": "python",
|
|
15
|
+
"typescript": "typescript",
|
|
16
|
+
"ts": "typescript",
|
|
17
|
+
"javascript": "typescript",
|
|
18
|
+
"js": "typescript",
|
|
19
|
+
}
|
|
20
|
+
ROLES = ("backend", "frontend")
|
|
21
|
+
EDITORS = ("vscode", "cursor", "windsurf", "zed", "idea", "pycharm", "webstorm", "none")
|
|
22
|
+
PY_FRAMEWORKS = ("auto", "fastapi", "django", "ninja", "drf", "django-orm", "sqlalchemy", "mongo")
|
|
23
|
+
JS_FRAMEWORKS = ("auto", "react-router", "tanstack-router")
|
|
24
|
+
DEFAULT_DENY = [
|
|
25
|
+
".env",
|
|
26
|
+
".env.*",
|
|
27
|
+
"**/.env",
|
|
28
|
+
"**/.env.*",
|
|
29
|
+
"**/*.pem",
|
|
30
|
+
"**/*.key",
|
|
31
|
+
"**/*.p12",
|
|
32
|
+
"**/*.lock",
|
|
33
|
+
"**/package-lock.json",
|
|
34
|
+
"**/pnpm-lock.yaml",
|
|
35
|
+
"**/yarn.lock",
|
|
36
|
+
"**/uv.lock",
|
|
37
|
+
"**/poetry.lock",
|
|
38
|
+
".git/**",
|
|
39
|
+
"**/secrets.*",
|
|
40
|
+
"**/*secret*.json",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(Exception):
|
|
45
|
+
def __init__(self, problems: list[str], path: Path | None = None) -> None:
|
|
46
|
+
self.problems = problems
|
|
47
|
+
self.path = path
|
|
48
|
+
where = f" in {path}" if path else ""
|
|
49
|
+
super().__init__(f"{len(problems)} problem(s){where}:\n" + "\n".join(f" - {p}" for p in problems))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class SourceConfig:
|
|
54
|
+
name: str
|
|
55
|
+
lang: str
|
|
56
|
+
role: str
|
|
57
|
+
root: str = "."
|
|
58
|
+
include: list[str] = field(default_factory=list)
|
|
59
|
+
exclude: list[str] = field(default_factory=list)
|
|
60
|
+
frameworks: list[str] = field(default_factory=lambda: ["auto"])
|
|
61
|
+
alias: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_python(self) -> bool:
|
|
65
|
+
return self.lang == "python"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class HttpLink:
|
|
70
|
+
frm: str
|
|
71
|
+
to: str
|
|
72
|
+
client_prefix: str = ""
|
|
73
|
+
server_prefix: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class HttpConfig:
|
|
78
|
+
links: list[HttpLink] = field(default_factory=list)
|
|
79
|
+
clients: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class View:
|
|
84
|
+
group: str
|
|
85
|
+
name: str
|
|
86
|
+
path: str
|
|
87
|
+
note: str = ""
|
|
88
|
+
source: str = "config"
|
|
89
|
+
|
|
90
|
+
def to_json(self) -> dict[str, str]:
|
|
91
|
+
return {"group": self.group, "name": self.name, "path": self.path, "note": self.note, "source": self.source}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class ConceptFind:
|
|
96
|
+
file: str
|
|
97
|
+
pattern: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class Concept:
|
|
102
|
+
attribute: str
|
|
103
|
+
label: str = "{value}"
|
|
104
|
+
find: list[ConceptFind] = field(default_factory=list)
|
|
105
|
+
mentions: bool = True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class ServerConfig:
|
|
110
|
+
port: int = 7341
|
|
111
|
+
editor: str = "vscode"
|
|
112
|
+
edit: bool = True
|
|
113
|
+
edit_deny: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
|
|
114
|
+
format: dict[str, list[str]] = field(default_factory=dict)
|
|
115
|
+
open_browser: bool = True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(slots=True)
|
|
119
|
+
class LayerRule:
|
|
120
|
+
name: str
|
|
121
|
+
order: list[str]
|
|
122
|
+
source: str = ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(slots=True)
|
|
126
|
+
class LogConfig:
|
|
127
|
+
level: str = "debug" # what the log file records
|
|
128
|
+
size_kb: int = 1024 # one file's size before it is rotated
|
|
129
|
+
keep: int = 5 # rotated files kept
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(slots=True)
|
|
133
|
+
class Config:
|
|
134
|
+
root: Path
|
|
135
|
+
name: str = ""
|
|
136
|
+
sources: list[SourceConfig] = field(default_factory=list)
|
|
137
|
+
http: HttpConfig = field(default_factory=HttpConfig)
|
|
138
|
+
page_params: dict[str, str] = field(default_factory=dict)
|
|
139
|
+
views: list[View] = field(default_factory=list)
|
|
140
|
+
concepts: list[Concept] = field(default_factory=list)
|
|
141
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
142
|
+
layer_rules: list[LayerRule] = field(default_factory=list)
|
|
143
|
+
adapters: list[str] = field(default_factory=list)
|
|
144
|
+
log: LogConfig = field(default_factory=LogConfig)
|
|
145
|
+
path: Path | None = None
|
|
146
|
+
detected: bool = False
|
|
147
|
+
|
|
148
|
+
def source(self, name: str) -> SourceConfig | None:
|
|
149
|
+
return next((s for s in self.sources if s.name == name), None)
|
|
150
|
+
|
|
151
|
+
def source_root(self, src: SourceConfig) -> Path:
|
|
152
|
+
return (self.root / src.root).resolve()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def state_dir(self) -> Path:
|
|
156
|
+
return self.root / ".stackthread"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------- loading
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def find_config(start: Path) -> Path | None:
|
|
163
|
+
"""The nearest stackthread.toml, or pyproject.toml with [tool.stackthread], walking up from `start`."""
|
|
164
|
+
for d in [start, *start.parents]:
|
|
165
|
+
if (d / CONFIG_FILE).is_file():
|
|
166
|
+
return d / CONFIG_FILE
|
|
167
|
+
pyproject = d / "pyproject.toml"
|
|
168
|
+
if pyproject.is_file():
|
|
169
|
+
try:
|
|
170
|
+
if "stackthread" in tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}):
|
|
171
|
+
return pyproject
|
|
172
|
+
except tomllib.TOMLDecodeError:
|
|
173
|
+
continue
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load(path: Path) -> Config:
|
|
178
|
+
try:
|
|
179
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
180
|
+
except tomllib.TOMLDecodeError as e:
|
|
181
|
+
raise ConfigError([f"not valid TOML: {e}"], path) from None
|
|
182
|
+
if path.name == "pyproject.toml":
|
|
183
|
+
data = data.get("tool", {}).get("stackthread", {})
|
|
184
|
+
return parse(data, path.parent, path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse(data: dict[str, Any], base: Path, path: Path | None = None) -> Config:
|
|
188
|
+
v = _Validator()
|
|
189
|
+
v.keys("", data, {"project", "sources", "http", "pages", "views", "concepts", "server", "rules", "adapters", "log"})
|
|
190
|
+
project = v.table("project", data.get("project", {}))
|
|
191
|
+
v.keys("project", project, {"name", "root"})
|
|
192
|
+
root = (base / v.string("project.root", project.get("root", "."))).resolve()
|
|
193
|
+
cfg = Config(root=root, name=v.string("project.name", project.get("name", "")) or root.name, path=path)
|
|
194
|
+
|
|
195
|
+
seen: set[str] = set()
|
|
196
|
+
for i, raw in enumerate(v.array("sources", data.get("sources", []))):
|
|
197
|
+
where = f"sources[{i}]"
|
|
198
|
+
s = v.table(where, raw)
|
|
199
|
+
v.keys(where, s, {"name", "lang", "role", "root", "include", "exclude", "frameworks", "alias"})
|
|
200
|
+
lang = LANGS.get(str(s.get("lang", "")).lower())
|
|
201
|
+
if lang is None:
|
|
202
|
+
v.problem(f"{where}.lang must be one of python, typescript (or javascript); got {s.get('lang')!r}")
|
|
203
|
+
continue
|
|
204
|
+
role = v.choice(f"{where}.role", s.get("role", "backend" if lang == "python" else "frontend"), ROLES)
|
|
205
|
+
name = v.string(f"{where}.name", s.get("name", "")) or f"{role}{i}"
|
|
206
|
+
if name in seen:
|
|
207
|
+
v.problem(f"{where}.name {name!r} is used twice")
|
|
208
|
+
seen.add(name)
|
|
209
|
+
known = PY_FRAMEWORKS if lang == "python" else JS_FRAMEWORKS
|
|
210
|
+
frameworks = v.strings(f"{where}.frameworks", s.get("frameworks", ["auto"]))
|
|
211
|
+
for fw in frameworks:
|
|
212
|
+
if fw not in known:
|
|
213
|
+
v.problem(f"{where}.frameworks: unknown {fw!r}; known: {', '.join(known)}")
|
|
214
|
+
alias = v.table(f"{where}.alias", s.get("alias", {}))
|
|
215
|
+
cfg.sources.append(
|
|
216
|
+
SourceConfig(
|
|
217
|
+
name=name,
|
|
218
|
+
lang=lang,
|
|
219
|
+
role=role,
|
|
220
|
+
root=v.string(f"{where}.root", s.get("root", ".")),
|
|
221
|
+
include=v.strings(f"{where}.include", s.get("include", [])),
|
|
222
|
+
exclude=v.strings(f"{where}.exclude", s.get("exclude", [])),
|
|
223
|
+
frameworks=frameworks,
|
|
224
|
+
alias={str(k): str(val) for k, val in alias.items()},
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if not (cfg.root / cfg.sources[-1].root).is_dir():
|
|
228
|
+
v.problem(f"{where}.root {cfg.sources[-1].root!r} is not a folder under {cfg.root}")
|
|
229
|
+
|
|
230
|
+
http = v.table("http", data.get("http", {}))
|
|
231
|
+
v.keys("http", http, {"links", "clients"})
|
|
232
|
+
for i, raw in enumerate(v.array("http.links", http.get("links", []))):
|
|
233
|
+
link = v.table(f"http.links[{i}]", raw)
|
|
234
|
+
v.keys(f"http.links[{i}]", link, {"from", "to", "client_prefix", "server_prefix"})
|
|
235
|
+
cfg.http.links.append(
|
|
236
|
+
HttpLink(
|
|
237
|
+
frm=v.string(f"http.links[{i}].from", link.get("from", "")),
|
|
238
|
+
to=v.string(f"http.links[{i}].to", link.get("to", "")),
|
|
239
|
+
client_prefix=v.string(f"http.links[{i}].client_prefix", link.get("client_prefix", "")),
|
|
240
|
+
server_prefix=v.string(f"http.links[{i}].server_prefix", link.get("server_prefix", "")),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
cfg.http.clients = v.strings("http.clients", http.get("clients", []))
|
|
244
|
+
|
|
245
|
+
pages = v.table("pages", data.get("pages", {}))
|
|
246
|
+
v.keys("pages", pages, {"params"})
|
|
247
|
+
cfg.page_params = {str(k): str(val) for k, val in v.table("pages.params", pages.get("params", {})).items()}
|
|
248
|
+
|
|
249
|
+
for i, raw in enumerate(v.array("views", data.get("views", []))):
|
|
250
|
+
view = v.table(f"views[{i}]", raw)
|
|
251
|
+
v.keys(f"views[{i}]", view, {"group", "name", "path", "note"})
|
|
252
|
+
path_value = v.string(f"views[{i}].path", view.get("path", ""))
|
|
253
|
+
if not path_value.startswith("/"):
|
|
254
|
+
v.problem(f"views[{i}].path must start with '/'; got {path_value!r}")
|
|
255
|
+
cfg.views.append(
|
|
256
|
+
View(
|
|
257
|
+
group=v.string(f"views[{i}].group", view.get("group", "Views")),
|
|
258
|
+
name=v.string(f"views[{i}].name", view.get("name", path_value)),
|
|
259
|
+
path=path_value,
|
|
260
|
+
note=v.string(f"views[{i}].note", view.get("note", "")),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
for i, raw in enumerate(v.array("concepts", data.get("concepts", []))):
|
|
265
|
+
c = v.table(f"concepts[{i}]", raw)
|
|
266
|
+
v.keys(f"concepts[{i}]", c, {"attribute", "label", "find", "mentions"})
|
|
267
|
+
finds = []
|
|
268
|
+
for j, f in enumerate(v.array(f"concepts[{i}].find", c.get("find", []))):
|
|
269
|
+
ft = v.table(f"concepts[{i}].find[{j}]", f)
|
|
270
|
+
v.keys(f"concepts[{i}].find[{j}]", ft, {"file", "pattern"})
|
|
271
|
+
pattern = v.string(f"concepts[{i}].find[{j}].pattern", ft.get("pattern", ""))
|
|
272
|
+
if "{value}" not in pattern:
|
|
273
|
+
v.problem(f"concepts[{i}].find[{j}].pattern must contain {{value}}")
|
|
274
|
+
finds.append(
|
|
275
|
+
ConceptFind(file=v.string(f"concepts[{i}].find[{j}].file", ft.get("file", "")), pattern=pattern)
|
|
276
|
+
)
|
|
277
|
+
attribute = v.string(f"concepts[{i}].attribute", c.get("attribute", ""))
|
|
278
|
+
if not attribute.startswith("data-"):
|
|
279
|
+
v.problem(f"concepts[{i}].attribute must be a data-* attribute; got {attribute!r}")
|
|
280
|
+
cfg.concepts.append(
|
|
281
|
+
Concept(
|
|
282
|
+
attribute=attribute,
|
|
283
|
+
label=v.string(f"concepts[{i}].label", c.get("label", "{value}")),
|
|
284
|
+
find=finds,
|
|
285
|
+
mentions=v.boolean(f"concepts[{i}].mentions", c.get("mentions", True)),
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
server = v.table("server", data.get("server", {}))
|
|
290
|
+
v.keys("server", server, {"port", "editor", "edit", "edit_deny", "format", "open_browser"})
|
|
291
|
+
port = server.get("port", 7341)
|
|
292
|
+
if not isinstance(port, int) or not 1024 <= port <= 65535:
|
|
293
|
+
v.problem(f"server.port must be an integer from 1024 to 65535; got {port!r}")
|
|
294
|
+
port = 7341
|
|
295
|
+
fmt = v.table("server.format", server.get("format", {}))
|
|
296
|
+
cfg.server = ServerConfig(
|
|
297
|
+
port=port,
|
|
298
|
+
editor=v.choice("server.editor", server.get("editor", "vscode"), EDITORS),
|
|
299
|
+
edit=v.boolean("server.edit", server.get("edit", True)),
|
|
300
|
+
edit_deny=DEFAULT_DENY + v.strings("server.edit_deny", server.get("edit_deny", [])),
|
|
301
|
+
format={str(k): v.strings(f"server.format.{k}", val) for k, val in fmt.items()},
|
|
302
|
+
open_browser=v.boolean("server.open_browser", server.get("open_browser", True)),
|
|
303
|
+
)
|
|
304
|
+
for lang, command in cfg.server.format.items():
|
|
305
|
+
if lang not in ("python", "typescript"):
|
|
306
|
+
v.problem(f"server.format.{lang}: use python or typescript")
|
|
307
|
+
if "{file}" not in command:
|
|
308
|
+
v.problem(f"server.format.{lang} must contain the argument {{file}}")
|
|
309
|
+
|
|
310
|
+
rules = v.table("rules", data.get("rules", {}))
|
|
311
|
+
v.keys("rules", rules, {"layers"})
|
|
312
|
+
for i, raw in enumerate(v.array("rules.layers", rules.get("layers", []))):
|
|
313
|
+
r = v.table(f"rules.layers[{i}]", raw)
|
|
314
|
+
v.keys(f"rules.layers[{i}]", r, {"name", "order", "source"})
|
|
315
|
+
order = v.strings(f"rules.layers[{i}].order", r.get("order", []))
|
|
316
|
+
if len(order) < 2:
|
|
317
|
+
v.problem(f"rules.layers[{i}].order needs at least two entries")
|
|
318
|
+
cfg.layer_rules.append(
|
|
319
|
+
LayerRule(
|
|
320
|
+
name=v.string(f"rules.layers[{i}].name", r.get("name", f"layers {i + 1}")),
|
|
321
|
+
order=order,
|
|
322
|
+
source=v.string(f"rules.layers[{i}].source", r.get("source", "")),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
adapters = v.table("adapters", data.get("adapters", {}))
|
|
327
|
+
v.keys("adapters", adapters, {"use"})
|
|
328
|
+
cfg.adapters = v.strings("adapters.use", adapters.get("use", []))
|
|
329
|
+
|
|
330
|
+
log = v.table("log", data.get("log", {}))
|
|
331
|
+
v.keys("log", log, {"level", "size_kb", "keep"})
|
|
332
|
+
size_kb, keep = log.get("size_kb", 1024), log.get("keep", 5)
|
|
333
|
+
if not isinstance(size_kb, int) or not 64 <= size_kb <= 102400:
|
|
334
|
+
v.problem(f"log.size_kb must be an integer from 64 to 102400; got {size_kb!r}")
|
|
335
|
+
size_kb = 1024
|
|
336
|
+
if not isinstance(keep, int) or not 1 <= keep <= 50:
|
|
337
|
+
v.problem(f"log.keep must be an integer from 1 to 50; got {keep!r}")
|
|
338
|
+
keep = 5
|
|
339
|
+
cfg.log = LogConfig(
|
|
340
|
+
level=v.choice("log.level", log.get("level", "debug"), ("debug", "info", "warning", "error")),
|
|
341
|
+
size_kb=size_kb,
|
|
342
|
+
keep=keep,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
names = {s.name for s in cfg.sources}
|
|
346
|
+
for i, lk in enumerate(cfg.http.links):
|
|
347
|
+
for side, value in (("from", lk.frm), ("to", lk.to)):
|
|
348
|
+
if value not in names:
|
|
349
|
+
v.problem(f"http.links[{i}].{side} names an unknown source {value!r}")
|
|
350
|
+
for i, rule in enumerate(cfg.layer_rules):
|
|
351
|
+
if rule.source and rule.source not in names:
|
|
352
|
+
v.problem(f"rules.layers[{i}].source names an unknown source {rule.source!r}")
|
|
353
|
+
|
|
354
|
+
if v.problems:
|
|
355
|
+
raise ConfigError(v.problems, path)
|
|
356
|
+
return cfg
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class _Validator:
|
|
360
|
+
def __init__(self) -> None:
|
|
361
|
+
self.problems: list[str] = []
|
|
362
|
+
|
|
363
|
+
def problem(self, message: str) -> None:
|
|
364
|
+
self.problems.append(message)
|
|
365
|
+
|
|
366
|
+
def keys(self, where: str, table: Any, allowed: set[str]) -> None:
|
|
367
|
+
if not isinstance(table, dict):
|
|
368
|
+
return
|
|
369
|
+
for key in table:
|
|
370
|
+
if key not in allowed:
|
|
371
|
+
hint = difflib.get_close_matches(key, sorted(allowed), n=1)
|
|
372
|
+
suggestion = f"; did you mean {hint[0]!r}?" if hint else f"; allowed: {', '.join(sorted(allowed))}"
|
|
373
|
+
self.problem(f"unknown key {(where + '.' if where else '') + key!r}{suggestion}")
|
|
374
|
+
|
|
375
|
+
def table(self, where: str, value: Any) -> dict[str, Any]:
|
|
376
|
+
if isinstance(value, dict):
|
|
377
|
+
return value
|
|
378
|
+
self.problem(f"{where} must be a table")
|
|
379
|
+
return {}
|
|
380
|
+
|
|
381
|
+
def array(self, where: str, value: Any) -> list[Any]:
|
|
382
|
+
if isinstance(value, list):
|
|
383
|
+
return value
|
|
384
|
+
self.problem(f"{where} must be an array of tables ([[{where}]])")
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
def string(self, where: str, value: Any) -> str:
|
|
388
|
+
if isinstance(value, str):
|
|
389
|
+
return value
|
|
390
|
+
self.problem(f"{where} must be a string; got {value!r}")
|
|
391
|
+
return ""
|
|
392
|
+
|
|
393
|
+
def strings(self, where: str, value: Any) -> list[str]:
|
|
394
|
+
if isinstance(value, list) and all(isinstance(x, str) for x in value):
|
|
395
|
+
return list(value)
|
|
396
|
+
self.problem(f"{where} must be a list of strings; got {value!r}")
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
def boolean(self, where: str, value: Any) -> bool:
|
|
400
|
+
if isinstance(value, bool):
|
|
401
|
+
return value
|
|
402
|
+
self.problem(f"{where} must be true or false; got {value!r}")
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
def choice(self, where: str, value: Any, options: tuple[str, ...]) -> str:
|
|
406
|
+
if value in options:
|
|
407
|
+
return str(value)
|
|
408
|
+
self.problem(f"{where} must be one of {', '.join(options)}; got {value!r}")
|
|
409
|
+
return options[0]
|
stackthread/detect.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Find the sources of a project without a config: React apps on Vite, Django and FastAPI backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from stackthread.config import Config, SourceConfig
|
|
12
|
+
from stackthread.paths import ALWAYS_SKIP
|
|
13
|
+
|
|
14
|
+
MAX_DEPTH = 4
|
|
15
|
+
PY_MARKERS = re.compile(r"\b(fastapi|django|djangorestframework|django-ninja|ninja|sqlalchemy|sqlmodel)\b", re.I)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Found:
|
|
20
|
+
sources: list[SourceConfig] = field(default_factory=list)
|
|
21
|
+
notes: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dirs(root: Path) -> list[Path]:
|
|
25
|
+
out = [root]
|
|
26
|
+
for dirpath, dirnames, _ in os.walk(root):
|
|
27
|
+
depth = len(Path(dirpath).relative_to(root).parts)
|
|
28
|
+
dirnames[:] = sorted(d for d in dirnames if d not in ALWAYS_SKIP and not d.startswith("."))
|
|
29
|
+
if depth >= MAX_DEPTH:
|
|
30
|
+
dirnames[:] = []
|
|
31
|
+
out += [Path(dirpath) / d for d in dirnames]
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _react_vite(folder: Path) -> bool:
|
|
36
|
+
pkg = folder / "package.json"
|
|
37
|
+
if not pkg.is_file():
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return False
|
|
43
|
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
44
|
+
has_vite = "vite" in deps or any((folder / f"vite.config.{e}").is_file() for e in ("ts", "js", "mts", "mjs"))
|
|
45
|
+
return "react" in deps and has_vite
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _python_backend(folder: Path) -> tuple[bool, list[str]]:
|
|
49
|
+
frameworks: list[str] = []
|
|
50
|
+
if (folder / "manage.py").is_file():
|
|
51
|
+
frameworks.append("django")
|
|
52
|
+
for name in ("pyproject.toml", "requirements.txt", "requirements.in", "setup.cfg", "Pipfile"):
|
|
53
|
+
file = folder / name
|
|
54
|
+
if file.is_file():
|
|
55
|
+
text = file.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
for m in PY_MARKERS.findall(text):
|
|
57
|
+
fw = m.lower()
|
|
58
|
+
if fw == "djangorestframework":
|
|
59
|
+
fw = "drf"
|
|
60
|
+
if fw in ("django-ninja", "ninja"):
|
|
61
|
+
fw = "ninja"
|
|
62
|
+
if fw not in frameworks:
|
|
63
|
+
frameworks.append(fw)
|
|
64
|
+
return bool(frameworks), frameworks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _python_root(folder: Path) -> Path:
|
|
68
|
+
"""Where module names start: `src/` in a src layout, else the folder itself."""
|
|
69
|
+
src = folder / "src"
|
|
70
|
+
if src.is_dir() and any((p / "__init__.py").is_file() for p in src.iterdir() if p.is_dir()):
|
|
71
|
+
return src
|
|
72
|
+
return folder
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect(root: Path) -> Found:
|
|
76
|
+
found = Found()
|
|
77
|
+
names: set[str] = set()
|
|
78
|
+
claimed: list[Path] = []
|
|
79
|
+
|
|
80
|
+
def name_for(folder: Path, fallback: str) -> str:
|
|
81
|
+
base = re.sub(r"[^\w-]", "-", folder.name if folder != root else fallback).strip("-") or fallback
|
|
82
|
+
candidate, i = base, 2
|
|
83
|
+
while candidate in names:
|
|
84
|
+
candidate, i = f"{base}{i}", i + 1
|
|
85
|
+
names.add(candidate)
|
|
86
|
+
return candidate
|
|
87
|
+
|
|
88
|
+
for folder in _dirs(root):
|
|
89
|
+
if any(folder.is_relative_to(c) for c in claimed):
|
|
90
|
+
continue
|
|
91
|
+
if _react_vite(folder):
|
|
92
|
+
rel = folder.relative_to(root).as_posix() if folder != root else "."
|
|
93
|
+
include = ["src/**/*"] if (folder / "src").is_dir() else []
|
|
94
|
+
found.sources.append(
|
|
95
|
+
SourceConfig(
|
|
96
|
+
name=name_for(folder, "web"), lang="typescript", role="frontend", root=rel, include=include
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
claimed.append(folder)
|
|
100
|
+
found.notes.append(f"React on Vite in {rel}")
|
|
101
|
+
continue
|
|
102
|
+
ok, frameworks = _python_backend(folder)
|
|
103
|
+
if ok:
|
|
104
|
+
py_root = _python_root(folder)
|
|
105
|
+
rel = py_root.relative_to(root).as_posix() if py_root != root else "."
|
|
106
|
+
exclude = ["**/migrations/**"] if "django" in frameworks or "drf" in frameworks else []
|
|
107
|
+
found.sources.append(
|
|
108
|
+
SourceConfig(name=name_for(folder, "api"), lang="python", role="backend", root=rel, exclude=exclude)
|
|
109
|
+
)
|
|
110
|
+
claimed.append(folder)
|
|
111
|
+
found.notes.append(f"Python backend ({', '.join(frameworks)}) in {rel}")
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def config_from(root: Path) -> Config:
|
|
116
|
+
found = detect(root)
|
|
117
|
+
cfg = Config(root=root.resolve(), name=root.resolve().name, sources=found.sources, detected=True)
|
|
118
|
+
return cfg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_toml(root: Path, found: Found) -> str:
|
|
122
|
+
lines = [
|
|
123
|
+
"# stackthread configuration. Every key is optional; see docs/configuration.md.",
|
|
124
|
+
"",
|
|
125
|
+
"[project]",
|
|
126
|
+
f'name = "{root.resolve().name}"',
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
for s in found.sources:
|
|
130
|
+
lines += ["[[sources]]", f'name = "{s.name}"', f'lang = "{s.lang}"', f'role = "{s.role}"', f'root = "{s.root}"']
|
|
131
|
+
if s.include:
|
|
132
|
+
lines.append("include = [" + ", ".join(f'"{x}"' for x in s.include) + "]")
|
|
133
|
+
if s.exclude:
|
|
134
|
+
lines.append("exclude = [" + ", ".join(f'"{x}"' for x in s.exclude) + "]")
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines += [
|
|
137
|
+
"# [http]",
|
|
138
|
+
'# links = [{ from = "web", to = "api", client_prefix = "/api", server_prefix = "/api" }]',
|
|
139
|
+
"",
|
|
140
|
+
"# Named states of pages, such as workflow stages, wizard steps or tabs:",
|
|
141
|
+
"# [[views]]",
|
|
142
|
+
'# group = "Checkout"',
|
|
143
|
+
'# name = "Payment step"',
|
|
144
|
+
'# path = "/checkout?step=payment"',
|
|
145
|
+
"",
|
|
146
|
+
"[server]",
|
|
147
|
+
'editor = "vscode" # vscode | cursor | windsurf | zed | idea | pycharm | webstorm | none',
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Language support: one subpackage per language, each with an extractor (facts per file) and a builder (graph)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""JavaScript and TypeScript: facts from the bundled Babel-based extractor (run with node), resolution, graph."""
|