@stacknyu/pi-check 0.0.0-stage → 1.1.0

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.
@@ -0,0 +1,320 @@
1
+ // Python: pytest per test file (inputs = static import closure + conftest chain + config),
2
+ // ruff per file, mypy / pyright per project.
3
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { kindOptions, rootLockfiles } from "../repo.mjs";
6
+ import { cleanEnv, baseOf, dirOf, isUnder, joinRel, matchAny, normRel, parentDirs, parseDuration, runProc, which } from "../util.mjs";
7
+
8
+ const MARKERS = new Set(["pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"]);
9
+ const TEST_FILE = /(^|\/)(test_[^/]*|[^/]*_test)\.py$/;
10
+ const SKIP = /(^|\/)(\.venv|venv|env|site-packages|__pycache__|\.tox|node_modules|build|dist)(\/|$)/;
11
+ const CONFIG_FILES = ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini", "tox.ini", "conftest.py", "requirements.txt", "requirements-dev.txt", "mypy.ini", ".mypy.ini", "ruff.toml", ".ruff.toml", "pyrightconfig.json"];
12
+
13
+ function dirsOfProjects(rc) {
14
+ let v = rc.memo.get("py:dirs");
15
+ if (v) return v;
16
+ const dirs = new Set();
17
+ for (const f of rc.listing().files) if (MARKERS.has(baseOf(f)) && !SKIP.test(f)) dirs.add(dirOf(f));
18
+ v = [...dirs].sort();
19
+ rc.memo.set("py:dirs", v);
20
+ return v;
21
+ }
22
+
23
+ function projectFiles(rc, dir) {
24
+ const k = `py:files:${dir}`;
25
+ let v = rc.memo.get(k);
26
+ if (v) return v;
27
+ const nested = dirsOfProjects(rc).filter((d) => d !== dir && isUnder(d, dir));
28
+ v = rc.filesUnder(dir).filter((f) => !SKIP.test(f) && !nested.some((n) => isUnder(f, n)));
29
+ rc.memo.set(k, v);
30
+ return v;
31
+ }
32
+
33
+ function parseImports(text) {
34
+ const mods = [];
35
+ for (const m of text.matchAll(/^[ \t]*import[ \t]+([\w. \t,]+)/gm)) for (const part of m[1].split(",")) mods.push({ mod: part.trim().split(/\s+as\s+/)[0].trim(), names: [] });
36
+ for (const m of text.matchAll(/^[ \t]*from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+\(?([^\n#)]*)/gm)) mods.push({ mod: m[1], names: m[2].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter((s) => s && s !== "*" && s !== "(") });
37
+ return mods;
38
+ }
39
+
40
+ class PyResolver {
41
+ constructor(rc, project) {
42
+ this.rc = rc;
43
+ this.project = project;
44
+ const d = project.dir;
45
+ this.roots = [d, joinRel(d, "src"), "."].filter((x, i, a) => a.indexOf(x) === i);
46
+ }
47
+ moduleFile(root, dotted) {
48
+ const p = joinRel(root, dotted.split(".").join("/"));
49
+ if (this.rc.has(`${p}.py`)) return `${p}.py`;
50
+ if (this.rc.has(`${p}/__init__.py`)) return `${p}/__init__.py`;
51
+ return undefined;
52
+ }
53
+ pkgInits(root, dotted) {
54
+ const parts = dotted.split(".");
55
+ const out = [];
56
+ for (let i = 1; i < parts.length; i++) {
57
+ const f = joinRel(root, `${parts.slice(0, i).join("/")}/__init__.py`);
58
+ if (this.rc.has(f)) out.push(f);
59
+ }
60
+ return out;
61
+ }
62
+ resolve(from, { mod, names }) {
63
+ const out = [];
64
+ const roots = [dirOf(from), ...this.roots];
65
+ let dotted = mod;
66
+ let searchRoots = roots;
67
+ if (mod.startsWith(".")) {
68
+ const dots = mod.match(/^\.+/)[0].length;
69
+ let base = dirOf(from);
70
+ for (let i = 1; i < dots; i++) base = dirOf(base);
71
+ dotted = mod.slice(dots);
72
+ searchRoots = [base];
73
+ if (!dotted) {
74
+ for (const n of names) {
75
+ const f = this.moduleFile(base, n);
76
+ if (f) out.push(f);
77
+ }
78
+ const init = joinRel(base, "__init__.py");
79
+ if (this.rc.has(init)) out.push(init);
80
+ return out;
81
+ }
82
+ }
83
+ for (const r of searchRoots) {
84
+ const f = this.moduleFile(r, dotted);
85
+ if (!f) continue;
86
+ out.push(f, ...this.pkgInits(r, dotted));
87
+ for (const n of names) {
88
+ const sub = this.moduleFile(r, `${dotted}.${n}`);
89
+ if (sub) out.push(sub);
90
+ }
91
+ break;
92
+ }
93
+ return out;
94
+ }
95
+ closure(files) {
96
+ const seen = new Set();
97
+ const stack = [...files];
98
+ while (stack.length) {
99
+ const f = stack.pop();
100
+ if (seen.has(f)) continue;
101
+ seen.add(f);
102
+ if (!f.endsWith(".py")) continue;
103
+ const mk = `py:imp:${this.rc.hash(f)}`;
104
+ let parsed = this.rc.hasher.memo.get(mk);
105
+ if (!parsed) {
106
+ parsed = parseImports(this.rc.text(f));
107
+ this.rc.hasher.memo.set(mk, parsed);
108
+ }
109
+ for (const imp of parsed) for (const g of this.resolve(f, imp)) if (!seen.has(g)) stack.push(g);
110
+ }
111
+ return seen;
112
+ }
113
+ }
114
+
115
+ function hasSection(rc, file, re) {
116
+ return rc.has(file) && re.test(rc.text(file));
117
+ }
118
+
119
+ function pythonCmd(rc, project) {
120
+ const tries = [joinRel(project.dir, ".venv/bin/python"), joinRel(project.dir, "venv/bin/python"), ".venv/bin/python", "venv/bin/python"];
121
+ for (const t of tries) {
122
+ for (const base of [rc.abs, rc.main]) {
123
+ const p = t === "." ? base : join(base, t);
124
+ if (existsSync(p)) return [p];
125
+ }
126
+ }
127
+ if ((rc.has(joinRel(project.dir, "uv.lock")) || rc.has("uv.lock")) && which("uv")) return [which("uv"), "run", "--no-sync", "python"];
128
+ if ((rc.has(joinRel(project.dir, "poetry.lock")) || rc.has("poetry.lock")) && which("poetry")) return [which("poetry"), "run", "python"];
129
+ return [which("python3") ?? which("python") ?? "python3"];
130
+ }
131
+
132
+ function toolBin(rc, project, name, py) {
133
+ for (const base of [rc.path(project.dir), rc.abs, rc.main, join(rc.main, project.dir)]) for (const v of [".venv/bin", "venv/bin"]) if (existsSync(join(base, v, name))) return [join(base, v, name)];
134
+ const onPath = which(name);
135
+ if (onPath) return [onPath];
136
+ return [...py, "-m", name];
137
+ }
138
+
139
+ function xmlUnescape(s) {
140
+ return String(s ?? "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#10;/g, "\n").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))).replace(/&amp;/g, "&");
141
+ }
142
+
143
+ const adapter = {
144
+ lang: "python",
145
+ version: 1,
146
+
147
+ detect(rc) {
148
+ if (!rc.config.languages.python) return [];
149
+ let dirs = dirsOfProjects(rc);
150
+ if (!dirs.length && rc.listing().files.some((f) => TEST_FILE.test(f) && !SKIP.test(f))) dirs = ["."];
151
+ const out = [];
152
+ for (const d of dirs) {
153
+ const files = projectFiles(rc, d);
154
+ if (!files.some((f) => f.endsWith(".py"))) continue;
155
+ const pyproject = joinRel(d, "pyproject.toml");
156
+ const kinds = [];
157
+ const meta = {};
158
+ if (files.some((f) => TEST_FILE.test(f))) kinds.push("test");
159
+ const ruffCfg = hasSection(rc, pyproject, /^\[tool\.ruff/m) || rc.has(joinRel(d, "ruff.toml")) || rc.has(joinRel(d, ".ruff.toml")) || rc.has("ruff.toml") || rc.has(".ruff.toml");
160
+ if (ruffCfg) kinds.push("lint");
161
+ if (hasSection(rc, pyproject, /^\[tool\.mypy\]/m) || rc.has(joinRel(d, "mypy.ini")) || rc.has(joinRel(d, ".mypy.ini")) || hasSection(rc, joinRel(d, "setup.cfg"), /^\[mypy\]/m)) {
162
+ kinds.push("typecheck");
163
+ meta.typechecker = "mypy";
164
+ meta.mypyFiles = /^\s*files\s*=/m.test(rc.text(pyproject) + rc.text(joinRel(d, "mypy.ini")) + rc.text(joinRel(d, "setup.cfg")));
165
+ } else if (hasSection(rc, pyproject, /^\[tool\.pyright\]/m) || rc.has(joinRel(d, "pyrightconfig.json"))) {
166
+ kinds.push("typecheck");
167
+ meta.typechecker = "pyright";
168
+ }
169
+ const enabled = kinds.filter((k) => kindOptions(rc.config, d, k) !== false);
170
+ if (enabled.length) out.push({ id: `python:${d}`, lang: "python", dir: d, name: d === "." ? "python" : d, kinds: enabled, meta });
171
+ }
172
+ return out;
173
+ },
174
+
175
+ units(rc, project, kind) {
176
+ const opts = kindOptions(rc.config, project.dir, kind) || {};
177
+ const files = projectFiles(rc, project.dir);
178
+ const rel = (f) => (project.dir === "." ? f : f.slice(project.dir.length + 1));
179
+ const cfg = [...CONFIG_FILES.map((c) => joinRel(project.dir, c)).filter((c) => rc.has(c)), ...rootLockfiles(rc), ...CONFIG_FILES.filter((c) => project.dir !== "." && rc.has(c) && c !== "conftest.py")];
180
+ const extra = (opts.inputs ?? []).length ? files.filter((f) => matchAny(opts.inputs, rel(f))) : [];
181
+ if (kind === "typecheck") return [{ target: project.dir, label: `${project.name} typecheck`, inputs: [...cfg, ...extra, ...files.filter((f) => /\.(py|pyi)$/.test(f) || baseOf(f) === "py.typed")] }];
182
+ if (kind === "lint") return files.filter((f) => f.endsWith(".py") && !matchAny(opts.exclude ?? [], rel(f))).map((f) => ({ target: f, label: f, inputs: [f, ...cfg] }));
183
+ if (kind === "test") {
184
+ const res = new PyResolver(rc, project);
185
+ const tests = files.filter((f) => TEST_FILE.test(f) && !matchAny(opts.exclude ?? [], rel(f)));
186
+ return tests.map((t) => {
187
+ const conftests = parentDirs(t)
188
+ .filter((d) => isUnder(d, project.dir) || d === ".")
189
+ .map((d) => joinRel(d, "conftest.py"))
190
+ .filter((c) => rc.has(c));
191
+ return { target: t, label: t, inputs: [...cfg, ...extra, ...res.closure([t, ...conftests])] };
192
+ });
193
+ }
194
+ return [];
195
+ },
196
+
197
+ group(project, kind, opts) {
198
+ return { name: `python:${project.dir}:${kind}`, limit: opts?.parallel ?? (kind === "lint" ? 2 : 1) };
199
+ },
200
+
201
+ batchSize: (kind) => (kind === "lint" ? 400 : kind === "test" ? 80 : 1),
202
+
203
+ argsKey(opts, project) {
204
+ return JSON.stringify({ args: opts?.args ?? [], env: opts?.env ?? {}, cmd: opts?.cmd ?? "", tool: project.meta });
205
+ },
206
+
207
+ async run({ rc, project, kind, units, opts, signal, nice, tmpDir }) {
208
+ const cwd = rc.path(project.dir);
209
+ const env = cleanEnv({ PYTHONDONTWRITEBYTECODE: "1", NO_COLOR: "1", ...(opts?.env ?? {}) });
210
+ const py = pythonCmd(rc, project);
211
+ const results = new Map();
212
+ const timeoutMs = parseDuration(opts?.timeout, 20 * 60000);
213
+ const toRepo = (p) => {
214
+ const abs = p.startsWith("/") ? p : join(cwd, p);
215
+ return abs.startsWith(`${rc.abs}/`) ? normRel(abs.slice(rc.abs.length + 1)) : normRel(joinRel(project.dir, p));
216
+ };
217
+ const errAll = (title, text) => {
218
+ for (const u of units) results.set(u.target, { status: "error", failures: [{ id: `${u.target}::[error]`, title, output: String(text).slice(-6000) }], output: "" });
219
+ };
220
+ const relFromProject = (f) => (project.dir === "." ? f : f.slice(project.dir.length + 1));
221
+
222
+ if (kind === "test") {
223
+ mkdirSync(tmpDir, { recursive: true });
224
+ const xml = join(tmpDir, `pytest-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.xml`);
225
+ // a project venv's python first; otherwise a pytest on PATH (it knows its own interpreter)
226
+ const runner = py[0].includes("/.venv/") || py[0].includes("/venv/") || py.length > 1 || !which("pytest") ? [...py, "-m", "pytest"] : [which("pytest")];
227
+ const args = [...runner.slice(1), "-q", "-p", "no:cacheprovider", "-o", "junit_family=xunit1", `--junitxml=${xml}`, ...(opts?.args ?? []), ...units.map((u) => relFromProject(u.target))];
228
+ const r = await runProc({ cmd: runner[0], args, cwd, env, signal, nice, timeoutMs });
229
+ let text = "";
230
+ try {
231
+ text = readFileSync(xml, "utf8");
232
+ rmSync(xml, { force: true });
233
+ } catch {}
234
+ if (!text) {
235
+ if (r.code === 5) for (const u of units) results.set(u.target, { status: "pass", failures: [], passed: 0, output: "", note: "no tests collected" });
236
+ else errAll(r.killed ? "pytest was stopped (timeout or cancelled)" : /No module named pytest/.test(r.stderr + r.stdout) ? "pytest is not installed for this project's Python" : "pytest produced no report", `${r.stdout}\n${r.stderr}`);
237
+ return { results, ms: r.ms, killed: r.killed };
238
+ }
239
+ const per = new Map(units.map((u) => [u.target, { passed: 0, failures: [] }]));
240
+ const byModule = new Map(units.map((u) => [relFromProject(u.target).replace(/\.py$/, "").split("/").join("."), u.target]));
241
+ for (const m of text.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
242
+ const attrs = Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], xmlUnescape(a[2])]));
243
+ let target;
244
+ if (attrs.file) {
245
+ const f = toRepo(attrs.file);
246
+ target = per.has(f) ? f : [...per.keys()].find((k) => k.endsWith(attrs.file));
247
+ }
248
+ if (!target) {
249
+ const cls = attrs.classname || attrs.name || "";
250
+ for (const [mod, t] of byModule) if (cls === mod || cls.startsWith(`${mod}.`) || mod.endsWith(cls)) target = t;
251
+ }
252
+ if (!target) continue;
253
+ const body = m[2] ?? "";
254
+ const bad = body.match(/<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/);
255
+ const x = per.get(target);
256
+ if (bad) {
257
+ const msg = xmlUnescape((bad[2].match(/message="([^"]*)"/) ?? [])[1] ?? "");
258
+ const cls = (attrs.classname ?? "").split(".").pop();
259
+ const name = attrs.name ? `${cls && cls !== baseOf(target).replace(/\.py$/, "") ? `${cls}::` : ""}${attrs.name}` : "[collection]";
260
+ x.failures.push({ id: `${target}::${name}`, title: `${target}::${name}${bad[1] === "error" ? " (error)" : ""}`, output: `${msg}\n${xmlUnescape(bad[3] ?? "")}`.trim().slice(-6000) });
261
+ } else if (!/<skipped/.test(body)) x.passed++;
262
+ }
263
+ for (const u of units) {
264
+ const x = per.get(u.target);
265
+ results.set(u.target, x.failures.length ? { status: "fail", failures: x.failures, passed: x.passed, output: "" } : { status: "pass", failures: [], passed: x.passed, output: "" });
266
+ }
267
+ return { results, ms: r.ms, killed: r.killed };
268
+ }
269
+
270
+ if (kind === "lint") {
271
+ const ruff = toolBin(rc, project, "ruff", py);
272
+ const r = await runProc({ cmd: ruff[0], args: [...ruff.slice(1), "check", "--output-format", "json", ...(opts?.args ?? []), ...units.map((u) => relFromProject(u.target))], cwd, env, signal, nice, timeoutMs });
273
+ let arr;
274
+ try {
275
+ arr = JSON.parse(r.stdout.slice(r.stdout.indexOf("[")));
276
+ } catch {}
277
+ if (!arr) {
278
+ errAll("ruff output could not be read", `${r.stdout}\n${r.stderr}`);
279
+ return { results, ms: r.ms, killed: r.killed };
280
+ }
281
+ const per = new Map(units.map((u) => [u.target, []]));
282
+ for (const d of arr) per.get(toRepo(d.filename))?.push(d);
283
+ for (const u of units) {
284
+ const d = per.get(u.target);
285
+ results.set(u.target, d.length ? { status: "fail", failures: d.map((x) => ({ id: `${u.target}:${x.code}:${x.message}`, title: `${u.target}:${x.location?.row ?? 0} ${x.code} ${x.message}`, output: "" })), output: "" } : { status: "pass", failures: [], passed: 1, output: "" });
286
+ }
287
+ return { results, ms: r.ms, killed: r.killed };
288
+ }
289
+
290
+ if (kind === "typecheck") {
291
+ const u = units[0];
292
+ let r;
293
+ const errs = [];
294
+ if (project.meta.typechecker === "pyright") {
295
+ const bin = toolBin(rc, project, "pyright", py);
296
+ r = await runProc({ cmd: bin[0], args: [...bin.slice(1), "--outputjson", ...(opts?.args ?? [])], cwd, env, signal, nice, timeoutMs });
297
+ try {
298
+ const j = JSON.parse(r.stdout.slice(r.stdout.indexOf("{")));
299
+ for (const d of j.generalDiagnostics ?? []) if (d.severity === "error") errs.push({ file: toRepo(d.file ?? ""), line: (d.range?.start?.line ?? 0) + 1, code: d.rule ?? "", msg: d.message });
300
+ } catch {}
301
+ } else {
302
+ const args = [...py.slice(1), "-m", "mypy", "--no-color-output", "--no-error-summary", "--show-error-codes", "--hide-error-context", ...(opts?.args ?? [])];
303
+ if (!project.meta.mypyFiles && !(opts?.args ?? []).length) args.push(".");
304
+ r = await runProc({ cmd: py[0], args, cwd, env, signal, nice, timeoutMs });
305
+ for (const line of r.stdout.split("\n")) {
306
+ const m = line.match(/^(.+?):(\d+):(?:\d+:)?\s*error:\s*(.*?)(?:\s+\[([\w-]+)\])?$/);
307
+ if (m) errs.push({ file: toRepo(m[1]), line: Number(m[2]), code: m[4] ?? "", msg: m[3] });
308
+ }
309
+ }
310
+ if (errs.length) results.set(u.target, { status: "fail", failures: errs.map((e) => ({ id: `${e.file}:${e.code}:${e.msg}`, title: `${e.file}:${e.line} ${e.code} ${e.msg}`, output: "" })), output: r.stdout.slice(-20000) });
311
+ else if (r.code !== 0) results.set(u.target, { status: "error", failures: [{ id: `${project.dir}:typecheck:[error]`, title: `${project.meta.typechecker} exited with an error`, output: `${r.stdout}\n${r.stderr}`.trim().slice(-6000) }], output: "" });
312
+ else results.set(u.target, { status: "pass", failures: [], passed: 1, output: "" });
313
+ return { results, ms: r.ms, killed: r.killed };
314
+ }
315
+ return { results, ms: 0 };
316
+ },
317
+ };
318
+
319
+ export default adapter;
320
+ export { parseImports, PyResolver };